🚀 API utils
A really basic client to work with an API endpoints requests.
🍿 Usage
If you are wondering why I built this, go to the Motivation section.
⚙️ Examples
Althought the utilities can be used in both the browser and Node, in these examples I'll be using node-fetch
as "assume a Node env".
Also, have in mind that, while the HTTP verbs' methods and the endpoint
method are public, the idea would be to subclass the client and expose specific method that will translate to internal requests. Yes, there's an example for that.
Configuring the client and making a request
To initialize a client, you'll need the following things:
import { apiClient } from '@homer0/api-utils';
import fetchClient from 'node-fetch';
const client = apiClient({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
login: 'auth/login',
},
});
- The
fetchClient
is the client that will actually make the requests. Having the fetch outside the API Client makes it so it can be used in both the browser and Node, as you can use node-fetch
, or window.fetch
. - The
url
is the base url, or entry point, of the API you'll work with. - The dictionary of endpoints the client can request to.
Now that you have the client, to make a request, you'll need to first generate the endpoint url, and then use it on the method for the HTTP verb you want to use:
type UserInfo = { id: number; name: string };
const info = await client.post<UserInfo>(client.endpoint('login'), {
username: 'Rosario',
password: 'pilar',
});
You have methods for the following HTTP verbs: get
, post
, put
, patch
, delete
and head
.
Endpoints with parameters
In the previous example, the request was quite simple: POST
to the login
endpoint; but you can have endpoints that require specific parameters to be sent. In this example, the profile
endpoint requires the user ID to be sent:
const client = apiClient({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
profile: 'users/:id/profile',
},
});
And we can send it in a dictionary, as the second parameter of the endpoint
method:
type UserProfile = { id: number; name: string; email: string; url: string };
const profile = await client.get<UserProfile>(client.endpoint('profile', { id: 2509 }));
The parameters' dictionary can be use to send any number of parameters to the endpoint, and if the client doesn't find a placeholder in the path definition, it will just add it as a query parameter:
const profile = await client.get<UserProfile>(
client.endpoint('profile', {
id: 2509,
format: 'json',
}),
);
Changing the placeholders' format
By default, the placeholders start with colon (:
) followed by the name of the parameter, but that can be changed by sending custom options to the "endpoints generator" the API client uses:
const client = apiClient({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
profile: 'users/{{id}}/profile',
},
endpointsGenerator: {
options: {
paramsPlaceholder: '{{%name%}}',
},
},
});
The placeholders now follow a handlebar-like syntax; you just have to keep the %name%
part, so the generator can replace it with the name of the parameter.
Endpoints generator
In the previous example, we used the endpointsGenerator
options object to change the placeholders' format, but what we actually did was sending specific constructor options to the EndpointsGenerator
service the client uses under the hood.
This service can be used stand alone, and it just takes care of generating endpoints:
import { endpointsGenerator } from '@homer0/api-utils';
const endpoints = endpointsGenerator({
url: 'https://api.homer0.dev',
endpoints: {
profile: 'users/:id/profile',
},
});
const url = endpoints.get('profile', { id: 2509 });
And when working with the API Client, the constructor options has the following object:
{
endpointsGenerator?: {
Class?: typeof EndpointsGenerator;
options?: Omit<EndpointsGeneratorOptions, 'url' | 'endpoints'>;
};
}
You can use it to send any custom options to the EndpointsGenerator
service that will be created, to the point that you can even send a custom subclass for the client to use.
import { EndpointsGenerator, apiClient } from '@homer0/api-utils';
class MyEndpointsGenerator extends EndpointsGenerator {
get(key: string, parameters: Record<string, unknown> = {}): string {
return super.get(key, {
...parameters,
magic: true,
});
}
}
const client = apiClient({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
profile: 'users/{{id}}/profile',
},
endpointsGenerator: {
Class: MyEndpointsGenerator,
options: {
paramsPlaceholder: '{{%name%}}',
},
},
});
And now all your requests will include magic
as a query parameter 🧙♀️!
Endpoints definitions
In all the previous examples, we've seen the endpoints dictionary being just a flat dictionary of strings, but you could also nest endpoints, and even add default query parameters:
const endpoints = {
login: 'auth/login',
users: {
profile: 'users/:id/profile',
list: {
path: 'users',
query: {
count: 20,
},
},
},
};
The first one is like the previous ones, you would call it with just 'login'
; but the rest are nested inside users, so, for example, you would have to do 'users.profile'
to get the profile endpoint.
The last one, list
, is not only nested, but it also an object with a path
and a query
, and it uses that format to be able to define a default query parameter: count
.
If the implementation doesn't send a different value for count
, the generated endpoint will always include count=20
.
Back to the client.
Let's say you have a special header in your project that you need to be included on every request, well, you won't have to do it manually, nor overwrite fetch
, as the client supports "default headers" from the constructor, or "on runtime":
const client = apiClient({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
profile: 'users/{{id}}/profile',
},
defaultHeaders: {
'x-development': true,
},
});
With that, all the requests will include a x-development
header from the get go.
But if you have specific logic to enable/disable the header, you can use the setDefaultHeaders
method:
if (something()) {
client.setDefaultHeaders({
'x-development': true,
});
}
Bearer token
If you are working with an API that requires authorization on every request, and that provides you with a bearer token when you authenticate, you could set it on the client in order to automatically include the Authorization
header on every request:
client.setAuthorizationToken('some-token');
Done, all the requests will include Authorization: Bearer some-token
.
Creating a service
As mentioned above, the idea of the client is to extend it and expose specific methods to interact with the API:
import { APIClient } from '@homer0/api-utils';
import fetchClient from 'node-fetch';
type UserInfo = { id: number; name: string };
type UserProfile = { id: number; name: string; email: string; url: string };
class MyAPI extends APIClient {
constructor() {
super({
fetchClient,
url: 'https://api.homer0.dev',
endpoints: {
login: 'auth/login',
users: {
profile: 'users/:id/profile',
list: {
path: 'users',
query: {
count: 20,
},
},
},
},
});
}
login(username: string, password: string): Promise<UserInfo> {
return this.post<UserInfo>(this.endpoint('login'), { username, password });
}
getProfile(id: number): Promise<UserProfile> {
return this.get<UserProfile>(this.endpoint('users.profile', { id }));
}
getUsers(count?: number): Promise<UserProfile[]> {
return this.get<UserProfile[]>(this.endpoint('users.list', { count }));
}
}
Then, your application can use MyAPI
as a service.
🤘 Development
As this project is part of the packages
monorepo, some of the tooling, like lint-staged
and husky
, are installed on the root's package.json
.
Tasks
Task | Description |
---|
lint | Lints the package. |
test | Runs the unit tests. |
build | Transpiles and bundles the project. |
types:check | Validates the TypeScript types. |
Motivation
This used to be part of the wootils
package, my personal lib of utilities, but I decided to extract them into individual packages, as part of the packages
monorepo, and take the oportunity to migrate them to TypeScript.
Nowadays there's almost no app that doesn't make requests to one or more external APIs, that's why I built this service.